Among UsのMod開発で対NPCモードを作ってみる (停止)
子供にいつでもゆるーくAmong Usを楽しんでもらうために、対NPCのモードを作ってみたい。
下記手順で開発する予定
フリープレイのダミープレイヤーの人数をさらに増やす
ローカルプレイでダミープレイヤーを4人追加
ローカルプレイでNPCプレイヤーを4人追加
NPCプレイヤーにランダムウォークさせる
NPCプレイヤーが自動で投票するようにする
移動禁止なタイミングで移動できないようにする
NPCプレイヤーがタスクの位置まで移動するようにする
NPCプレイヤーがインポスターなら、キルできるようにする
NPCプレイヤーがクルーメイトなら、タスクを完了できるようにする
NPCプレイヤーを増やしたり減らしたりできるようボタンを追加する
ホスト以外のプレイヤーにもNPCプレイヤーを表示する
フリープレイのダミープレイヤーの人数をさらに増やす
ダミープレイヤーが追加されるタイミングを探すために、
プレイヤーが追加されそうなメソッドを見つけてログを吐き出せるようにする。
引数も知りたいから、prefixを追加。
それっぽいのは GameData.AddPlayer メソッド。
これにprefixを追加してログを確認してみる。
TODO gitのコミットを貼る
called AddPlayer, args: PlayerControl(0, ???)
called AddPlayer, args: PlayerControl(1, ???)
called AddPlayer, args: PlayerControl(2, ???)
called AddPlayer, args: PlayerControl(3, ???)
called AddPlayer, args: PlayerControl(4, ???)
called AddPlayer, args: PlayerControl(5, ???)
called AddPlayer, args: PlayerControl(6, ???)
名前が表示できてないのは気になるけど、間違いなくAddPlayerが叩かれていることを確認。
調べるまでしらんかったんやけど、Unityにはprefabってのがあるらしい。
コピー可能なオブジェクトとして用意したものっぽい。
PlayerのPrefabがあるかもなので、探す。
AmongUsClient
TutorialManager
EndGameManager
IntroCutscene
にPlayerPrefabがあった。
とりあえず上から順番に試してみる。
AmongUsClientのPlayerPrefabをつかってプレイヤーが追加された。
https://scrapbox.io/files/61a5f740d59d6e001d544059.png
ただし名前がないし、Killすることもできなかった。
複製してから、設定を変えてあげないといけないんだと思う。
プレイヤーを2人分追加して、IDと名前をみたら
playerId: 0, playerName: おっくん
playerId: 1, playerName: ダミー 1
playerId: 2, playerName: ダミー 2
playerId: 3, playerName: ダミー 3
playerId: 4, playerName: ダミー 4
playerId: 5, playerName: ダミー 5
playerId: 6, playerName: ダミー 6
playerId: 255, playerName:
playerId: 255, playerName:
PlayerIdが255がデフォで、名前はないのがデフォっぽい。
まずはこれから設定する。
IDは連番になってることから、MAX+1を設定しておけば大丈夫そう。
やけど、すでに連番が振られてるってことは、最大値を取るようなのがあるんじゃないかな?
ってことで探す。
get max id で探したけど良いのはなさげ。
next id で探したけど、良いのはなさげ。
get id で探したら、 GetAvailableId があった。これ使えそう。
次は名前やけど、StringNamesというオブジェクトがある。
ここにDummyというのがあるし、他にも使えそうな名前がある。
StringNames で調べると、TranslationController というのが出てくる。
ここにStringNamesを受け取るメソッドがあるから、それを使ってみる。
TranslationController.Instance.GetString(StringNames.Dummy) でダミーの文言は取れたけど、
PlayerControll.SetName で RaiseExceptionIfNecessary Exceptionがでる。
非同期処理に問題があるっていう例外っぽいけど…。
色々試した結果、処理順序に問題があったみたい。
先に GameData.Instance.AddPlayer(pc) をやっとかないと、SetNameに失敗した。
ただ、次は表示されなくなった。
https://scrapbox.io/files/61a67ca0391981002085da2c.mp4
キルが出来てるからポジションはそこにあるのは間違いない。
viewable 的なのの設定が必要なのか、 color 的なのの設定が必要なのか。その辺を探す。
viewable的なものとしては、 enabled があったからtrueを入れておく。
色を付けるために、 SetColor を叩く。引数はint型。
AllColors はそれっぽいのがない。 color list もそれっぽいのがない。
colors は get_PlayerColors が見つかった。 Il2CppStructArray<Color32> 型。
AvailableColors も見つかった。 HashSet<int> 型。
AvailableColors が便利そうやなーと思ったけど、PlayerTabはinstanceが取れない。
PlayerColor は Color32型で、intにはならない。このことから、SetColorの引数はcolorのindexなのかも?ということで、
idをそのまま流してみる。
まだ表示されない。
PlyaerControlにSpawnFlagsというのがある。
どっから沸きの制御がありそう?
Spawnで探したら InnerNetObject に Despawn ってのを見つけた。
同じところに Spawn もありそう。
あったから、PlayerControlを渡す!
まだ表示はされない。
PlayerIdを設定する前は表示されていたから、PlayerIdと関連づく何かで制御があるのかなー。
とりあえずデータをそろえようとしても表示されないので、
フリープレイの開始時に発火する何かによって描画されるプレイヤーが決定されるのかも?と疑う。
id初期値が表示されてた理由は分からないけど、特殊処理なのかも。
フリープレイでの実装はこの辺にする。
なんで表示されないかの問題はあるけど、フリープレイで詰めるべきことじゃないかなーと判断。
ローカルプレイでダミープレイヤーを4人追加
ダミーであるかはいったんおいといて、ロビーでNPCを追加することで4人を満たすことを目指す。
っていっても、プレイヤーを追加する方法はすでに把握してるから、
後はどこに追加するかってだけなんやけど、 GameStartManager にでも追加しておけばOK。
あ、4人追加じゃなくて、自分がいるから +3人や。
でも実際15人でも処理自体は同じ。
TODO gitのコミットを貼る
https://scrapbox.io/files/61a7207ff7c79a00227a83ef.mp4
フリープレイでは表示されなかったプレイヤーも問題なく表示出来た。
やっぱり処理タイミングな気がするね。
NPCプレイヤーにランダムウォークさせる
ランダムウォークさせるっていうか、処理の度にいずれかの方向に進もうとさせるという感じ。
いずれかの方向 はランダムで決定する。
まず初めに、移動の仕組みを想像する。
自身のプレイしているキャラクターであれば、
移動のキー入力 → 更新処理でキー入力を検知 → ポジションを変更 → アニメーションで見た目も移動
って流れだと思う。
まずこれを確認するために、自キャラの移動方法を探す。
一方向に進むようにしたい。
移動っぽいワードでメソッドを探していく。
move
Action.MoveHorizontal や Action.MoveVertical が怪しいかな?
CustomNetworkTransform にもmoveっぽいのがいくつかある。自キャラ関係ではないと思うけど
walk
PlayerAnimator.WalkPlayerTo ってのが移動時に叩かれる気がする
run
PlayerPhisics.RunAnim、SkinLayer.SetRun
ここまで探したけど、移動を流し込むところはなさそう。
じゃあ直接ポジションを変えてしまいましょう。
直接ポジションを変えてしまうなら、自キャラかどうかは関係ないね。
positionはPlayerControlから見えるところに2つあって、ひとつがpositionで、もう一つがlocalPosition。
positionを直接変えると色んな処理がスキップされちゃうけど、
localPositionを変えたらそのあとアニメーションが実行される。
ってことで、変えるならlocalPositionかな。
試してると、自キャラと他キャラの処理が違いそう。
自キャラの位置をずらしてもアニメーションは走らないけど、他キャラの位置をずらすと走る。
あと自キャラと他キャラでずらす量を一致させてるのに、自キャラだけ遠くまで進む。
処理を入れているのは PlayerControl.FixedUpdate の postfix。
ログを出しても呼ばれている回数は変わらなさそう。
GetTruePositionは自キャラでだけ叩かれてるみたいやけど、変わった値を返してるとかはなさそう。
ん-わからない。
自キャラを走らせるのはいったん諦めよう。
TODO gitのコミットを貼る
https://scrapbox.io/files/61a84ea1533038001d4cde56.mp4
キャラの移動を反映している箇所を特定したい。
Hoge.FixedUpdateで移動させようとすると一定時間毎に呼ばれるが、
Hoge.Update で移動させようとすると、1フレーム毎に呼ばれることになる。
この差によって想定しない移動速度が出ると困る。
どこで更新しているかは分からなかったけど、1秒に2.5というのがMaxっぽい。
UnityExplorerでTransformをみた結果、真横に移動したときはVelocityは2.5が最大で、
斜めに移動した場合は、1.76, 1.76くらいになる。
辺A, Bが2.5の三角形があったときの、斜めの長さ3.536になるから妥当っぽい。
この2.5というのがどこかに保存されてるはずやから探す。
PlayerPhysicsにSpeed、TrueGhostSpeed、TrueSpeedという項目があり、
それぞれ2.5, 3, 2.5になっているのでこれっぽい。
これがどこから入っているのかは分からないけど、この値を使えば上限を出せそう。
FixedUpdateなら0.02sに1回とし、1回真横に走る場合は2.5/50の0.05とできる?
Updateなら1/64秒に1回なので、1回真横に走る場合は2.5/64とできる?
と思う。
TODO gitのコミットを貼る
NPCプレイヤーが自動で投票するようにする
デバッグのためにも、NPCの投票は必須になる。
毎回会議時間いっぱい待つなんて嫌やからね。
もちろん、死んだNPCが投票したら困るので、投票できるNPCのみが投票できるようにしなくちゃいけない。
DoVoteというメソッドがDummyBehaviourにはある。
任意の投票先に投票することを考えると、DummyBehaviourを使うわけにはいかないから、
DoVoteがやってそうなことをやるしかない。
想像では、会議を管理するオブジェクトがって、
Hudはその値をみて、誰が投票してるなーとか、誰が投票してないなーとか、
誰が会議開いたとか、会議が開かれた理由とか、誰が誰に投票したとかを管理している気がする。
meeting manager
MeetingRoomManager それっぽいのが見つかった
meeting hud
MeetingHud あるのは知ってたけど、Hudで管理してるとは思えないんよなぁ…
MeetingRoomManager を見るけど、持ってる項目が少ない。
reporterとtargetはわかるけど、それ以外使えそうなものがない。
じゃあ仕方がないから MeetingHud をみる。
voteの一覧っぽいのはぱっとみない。
plyaerの一覧っぽいのがあった。 playerStates
playersでsearchしてたからあぶなかった。
playerStates には、プレイヤー数分の PlayerVoteArea があって、投票に関わる情報が入ってる。
amDead: 死んだプレイヤーはここがtrue
didReport: 自分がreportしたかどうか
didVote: 自分が投票したかどうか。外から変えれなさそ。VotedForをプレイヤーIDに変えるとTrueに変わる
TargetPlayerId: PlayerVoteAreaの持ち主
voteComplete: 投票したかどうかっぽいけど、ずっとtrueやったりするから分からない
VotedFor: ここで指定したplayerIdに投票される
この辺りをコントロールすれば良さそう。
上記のパラメータを変えても、投票しました。みたいなアイコンが表示されない。
アイコン表示とかは別のトリガーがありそう。
SetVote(byte PlayerId) を使えば、アイコンも連動することが分かった。
音が出ないのがさみしい感じあるけど、見た目と値をまとめて更新できるのはSetVote。
ただ個別で管理するより、もっと上から流し込めるのがある気がする。
MeetingHudのメソッドでまとめて管理できそうなのないかな。
デバッグのためのやつ。
called PrefixCastVote, srcPlayerId: 3, suspectPlayerId: 253
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 6
called PrefixCastVote, srcPlayerId: 1, suspectPlayerId: 0
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 5
called PrefixCastVote, srcPlayerId: 4, suspectPlayerId: 2
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 4
called PrefixCastVote, srcPlayerId: 2, suspectPlayerId: 4
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 3
called PrefixCastVote, srcPlayerId: 5, suspectPlayerId: 4
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 2
called PrefixCastVote, srcPlayerId: 6, suspectPlayerId: 4
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 1
called PrefixCastVote, srcPlayerId: 0, suspectPlayerId: 4
called PrefixVotingComplete, states: UnhollowerBaseLib.Il2CppStructArray`1MeetingHud+VoterState, exiled: GameData+PlayerInfo, tie: True called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 0, parent: UnityEngine.Transform
called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 0, parent: UnityEngine.Transform
called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 0, parent: UnityEngine.Transform
called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 0, parent: UnityEngine.Transform
called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 1, parent: UnityEngine.Transform
called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 2, parent: UnityEngine.Transform
called PrefixBloopAVoteIcon, voterPlayer: GameData+PlayerInfo, index: 3, parent: UnityEngine.Transform
called PostfixCheckForEndVoting
called PostfixGetVotesRemaining, result: 0
CastVote を叩くことで、投票、アイコン表示、音、全部そろってる。
投票可能時間になったら、各NPCランダムで誰かに投票するようにすれば、
とりあえず会議できるようになる!
called MeetingHudPatch.PostfixUpdate, introIsActive: True
called MeetingHudPatch.PostfixUpdate, introIsActive: False
MeetingIntro.isActiveAndEnabled をみれば投票画面が開かれているか分かる。
TODO gitのコミットを貼る
https://scrapbox.io/files/61a9ffd05c3623001e7ef13e.mp4
移動禁止なタイミングで移動できないようにする
ポジションを直接いじることから、移動不可なタイミングでも移動できてしまう。
そうならないように、移動してはいけないタイミングでは移動できなくする。
具体的には、会議中、アニメーションの再生中。
他にもみつかるかもやけど、とりあえずこのタイミングに動作しないようにしたい。
とおもったけど、PlayerControl.CanMoveで十分な気がしてきた。
moveableがTrueな場合でも、CanMoveはFalseなことがあって、ひとまずの目的にはあってそう。
とおもったけど、プレイヤーがボタンを開くだけでCanMoveはFalseになってしまう。
プレイヤーがタスクを開いても同じ。
CanMoveは操作プレイヤーのステータスを返していそう。
丁寧に移動可能かを見ることにする。
ゲーム開始時から順番に行くと、
ロビー(プレイ前の船
カウントダウン
Shhh
ロール表示
ロール役割
行動
タスク
キルアニメーション
会議
終了のリザルト表示
という流れ。
この中で移動できるのは行動だけ。
それ以外は全部行動できない。
上から順番に全体に共通して影響する項目の判定方法を見ていく。
ロビー
LobbyBehaviourという振る舞いがあるけど、これを持ってるオブジェクトがあるはず。
Lobbyオブジェクトは SkeldShipRoom というものをもってる。
こいつにStartがあるから、ロビーの始まりはつかめるかも。
とおもったけど、ロビー開始時だけじゃなく、ゲーム開始時にも叩かれてる。
暫定でGameStartManager.Startに当てとく。
カウントダウン
ここも一応動いていいところやけど、とりあえず調べてみる。
GameStartManagerで管理してるんじゃないかなーって予想。
Shhhまでに叩かれるメソッドめっちゃ少ない
called GameStartManagerPatch.PostfixStart
called GameStartManagerPatch.PostfixBeginGame
開始のボタンを押したら BeginGame が叩かれるから、とりあえずこれをトリガーにはできそう
Shhh
シー!!ってやつね。
ShhhBehaviourってのがあって、多分この振る舞いをもってるのがHudManager。
ShowEmblem(bool shhh)
多分このメソッド。
このメソッド作りあまそうやから、いつかアプデのときに動かなくなりそうやなー。
ロール表示・ロール役割
この中に2人インポスターがいる! みたいなやつと、
そのあとにある、あなたはクルーメイトです。タスクしましょう。みたいなやつ。
IntroCutsceneみたいなやつが動いてそう。
called IntroCutscenePatch.PrefixBeginCrewmate, yourTeam: Il2CppSystem.Collections.Generic.List`1PlayerControl called IntroCutscenePatch.PostfixSetUpRoleText
called IntroCutscenePatch.PostfixOnDestroy
うん、あたり。
行動
移動して、タスクして、キルして、ボタン押して、みたいなタイミング。
基本的に、他の状態じゃなければこの状態。って扱いになると思う。
例えば、ロールの説明のあと行動ターンに遷移するけど、
ロール説明のOnDestroyイベントに行動ターンへの遷移を書くことになると思う。
他にも会議の終了とか、キルアニメーションの終了とか。
タスク
NPCはたぶん実際にはタスクできないと思う。
タスクの範囲に入って、一定時間停止いてたらタスクが進捗。みたいな作りになるはず。
一定時間はプレイヤーが実際にタスクした時間 x 一定割合 を考えてるけど、ここでは関係ないので割愛。
タスクとキルアニメーションは全プレイヤーが同時に管理されるものじゃないから、
管理するのは難しいと思う。
会議
会議の大まかな流れは、
誰かがボタンを押す or 死体が発見される
投票待ち
投票
結果表示
追い出すシーン
って感じ。
移動不可なのは上記の全部。
会議の開始の合図から追い出されるところまで停止して、
追い出しが終わったところで行動ターンに戻る。
called MeetingHudPatch.PrefixServerStart, reporter: 0
called MeetingHudPatch.PrefixCoIntro, reporter: GameData+PlayerInfo, reportedBody: GameData+PlayerInfo, deadBodies: UnhollowerBaseLib.Il2CppReferenceArray`1GameData+PlayerInfo called MeetingHudPatch.PostfixStart
called MeetingHudPatch.PostfixClose
called ExileControllerPatch.PostfixAwake
called ExileControllerPatch.PrefixBegin, exiled: GameData+PlayerInfo, tie: False
called ExileControllerPatch.PostfixAnimate
called MeetingHudPatch.PostfixOnDestroy
called ExileControllerPatch.PostfixReEnableGameplay
called ExileControllerPatch.PostfixWrapUp
開始タイミングで早く叩かれるのは MeetingHud.ServerStart やけど、
MeetingHud.Start とほぼ同時やから MeetingHud.Start で良さそう。
追放画面の開始は ExileController.Awake 。
最後は ExileController.WrapUp 。
WrapUpが最後でいいのか?という疑問があるけど、とりあえず最後に叩かれてるからそれを見とく。
終了のリザルト表示
EndGameManager というのがあるから多分これ。
実際はこのタイミングではもう結果だけで、NPCは船から消えているべきなので、
動けるかどうかってのは関係ないけど、状態遷移的に一応つかめるようにしておきたい。
ってことで、 EndGameManager.Start に挟みこんで、ゲームの終了をキャッチ。
流れとしてはこんな感じ。
TODO gitのコミットを貼る
status: Lobby
status: CountDown
status: Shhh
status: Teammates
status: RoleDescription
status: Action
status: Meeting
status: Exile
status: Action
status: Meeting
status: Exile
status: Action
status: Meeting
status: Exile
status: Action
status: GameEnd
status: Lobby
忘れてたけど、エアシップだけは沸き位置選択があった。
その時も移動できないようにしとかないと。
ってことでいくつか調べてみたけど、minigameに上手く処理を挟めない。
airshipの場合はexile終了と同時に適当なspwan位置に飛ばすようにしないとかなー。
NPCプレイヤーがタスクの位置まで移動するようにする
壁とかあるし難しいけど、とりあえずいくつかに分解して処理を作る。
タスクの一覧を取得
一番近い実行可能なタスクを取得
距離を縮められる方向に位置を変更
緊急タスクは他タスクより優先されるようにする
ここまでできたら
障害物を検知して障害物のない方向に進む
んでもう一歩進めて
障害物に当たらないルートの中で一番近い実行可能なタスクを取得
ってな感じかな。
多分難しいから、無理そうならQ学習みたいな強化学習をつかってルート割り出すようにするかも。
タスクの一覧
プレイヤーが持ってるタスクの一覧は、 PlayerInfo.Tasks にList<TaskInfo>型である。
ただ、 TaskInfo には完了したか、タスクのID、タスクタイプのIDくらいしか情報がない。
タスクタイプのIDは、 TaskTypes enumの定義値が入りそう。
とおもったけど、実際にCompleteに変わるタスクとやったタスクでTypeIdが一致する感じがない。
タスクの位置はどこにあるのか……。
ShipStatusにCommonsTasks、LongTasks、NormalTasks、SpecialTasksがあって、
それぞれ配列でNormalPlayerTask型が入ってる。
NormalPlayerTask型の親にあたるPlayerTask型にはLocationsという位置を表現する情報がある。
ワイヤータスクみたいな複数回続くタスクは、Locationsも複数になりそう。
PlayerControl.myTasks のが使い勝手がいい?
やってみたら、こっちに全部情報が集まってるっぽい。
自キャラだけじゃなくて、NPCのタスクの一覧も見れる。問題なさそう。
一番近い実行可能なタスクを取得
一番近いは直線距離として、実行可能をどう評価するか。
わかりやすいのは完了しているかどうか。
完了済みのタスクは実行可能じゃないし、未完了のタスクは実行可能と言える。
あと微妙なのが、開始してから1分後に次のアクションを行なえるようなタスク。
値的にはTaskTimerで、TimerがあるかはShowTaskTimer、
状況はTimerStartedがNoStartedからStartedになって、Finishedになる。
NoStartedかFinishedが実行可能なタスクと言えそう。
TaskTimerに値をいれて、TimerStartedをStartedにすれば、Timerが進み始め、時間が経過したらFinishedになるっぽいかな
Skeldで試すから扉開けはいったんおいといて、一番近い実行可能なタスクの取得はできそう。
ちなみに緊急は、
停電がElestricTask、
コミュがHudOverrideTask、
O2がNoOxyTask、
リアクターがReactorTask
って感じ。
リアクターみたいな2箇所でやるタスクはLocationsが2つになる。
片方でアクションしてても、もう片方でも同時にやってもらえるまでは、Locationsからなくならない
O2もLocationsに2つ要素があり、解決したほうからLocationsからなくなる
距離を縮められる方向に位置を変更
いったん緊急はおいといて、壁にぶつかるやろうけど目的地に走るようにする。
ってことで、タスクの位置を取得し、タスクの方向に移動しようとするところはできたけど、別の問題が。
https://scrapbox.io/files/61ad6d2e8c5ef70022d40bdc.mp4
どうも、タスクに近づこうとする → 何らかの処理で正しい位置に戻される → タスクに近づこうとする
を繰り返してる感じがある。
例えば壁にめり込まないようにとか、存在しない通路を走らないようにとか、誤差の修正とか、色んな役割があると思うけど、
その処理によって位置を戻すように書き換えられてる感じがする。
もう一回NPCを移動する方法を検討したほうが良さそう。
通信の問題があるから、通信側から解決して再度NPCモード開発に取り掛かるので、
いったんこのアプローチはClose
TODO
すでに使われている色をNPCがとらないようにする
見栄えが分かりやすいように、使われていない帽子やスキンを身に着けるようにする
Airshipで変なとこにスポーンする
更新履歴